68a3981f3c1d41940ec87fd4693ddb76d5b51b7c
[nextcloud-desktop.git] /
1 /*
2  * Copyright (C) 2023 by Claudio Cambra <claudio.cambra@nextcloud.com>
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful, but
10  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12  * for more details.
13  */
14
15 import FileProvider
16 import Foundation
17 import NCDesktopClientSocketKit
18 import NextcloudKit
19 import NextcloudFileProviderKit
20 import OSLog
21
22 let AuthenticationTimeouts: [UInt64] = [ // Have progressively longer timeouts to not hammer server
23     3_000_000_000, 6_000_000_000, 30_000_000_000, 60_000_000_000, 120_000_000_000, 300_000_000_000
24 ]
25
26 extension FileProviderExtension: NSFileProviderServicing, ChangeNotificationInterface {
27     /*
28      This FileProviderExtension extension contains everything needed to communicate with the client.
29      We have two systems for communicating between the extensions and the client.
30
31      Apple's XPC based File Provider APIs let us easily communicate client -> extension.
32      This is what ClientCommunicationService is for.
33
34      We also use sockets, because the File Provider XPC system does not let us easily talk from
35      extension->client.
36      We need this because the extension needs to be able to request account details. We can't
37      reliably do this via XPC because the extensions get torn down by the system, out of the control
38      of the app, and we can receive nil/no services from NSFileProviderManager. Once this is done
39      then XPC works ok.
40     */
41     func supportedServiceSources(
42         for itemIdentifier: NSFileProviderItemIdentifier,
43         completionHandler: @escaping ([NSFileProviderServiceSource]?, Error?) -> Void
44     ) -> Progress {
45         Logger.desktopClientConnection.debug("Serving supported service sources")
46         let clientCommService = ClientCommunicationService(fpExtension: self)
47         let fpuiExtService = FPUIExtensionServiceSource(fpExtension: self)
48         let services: [NSFileProviderServiceSource] = [clientCommService, fpuiExtService]
49         completionHandler(services, nil)
50         let progress = Progress()
51         progress.cancellationHandler = {
52             let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)
53             completionHandler(nil, error)
54         }
55         return progress
56     }
57
58     @objc func sendFileProviderDomainIdentifier() {
59         let command = "FILE_PROVIDER_DOMAIN_IDENTIFIER_REQUEST_REPLY"
60         let argument = domain.identifier.rawValue
61         let message = command + ":" + argument + "\n"
62         socketClient?.sendMessage(message)
63     }
64
65     private func signalEnumeratorAfterAccountSetup() {
66         guard let fpManager = NSFileProviderManager(for: domain) else {
67             Logger.fileProviderExtension.error(
68                 "Could not get file provider manager for domain \(self.domain.displayName, privacy: .public), cannot notify after account setup"
69             )
70             return
71         }
72
73         assert(ncAccount != nil)
74
75         fpManager.signalErrorResolved(NSFileProviderError(.notAuthenticated)) { error in
76             if error != nil {
77                 Logger.fileProviderExtension.error(
78                     "Error resolving not authenticated, received error: \(error!.localizedDescription)"
79                 )
80             }
81         }
82
83         Logger.fileProviderExtension.debug(
84             "Signalling enumerators for user \(self.ncAccount!.username) at server \(self.ncAccount!.serverUrl, privacy: .public)"
85         )
86
87         notifyChange()
88     }
89
90     func notifyChange() {
91         guard let fpManager = NSFileProviderManager(for: domain) else {
92             Logger.fileProviderExtension.error(
93                 "Could not get file provider manager for domain \(self.domain.displayName, privacy: .public), cannot notify changes"
94             )
95             return
96         }
97
98         fpManager.signalEnumerator(for: .workingSet) { error in
99             if error != nil {
100                 Logger.fileProviderExtension.error(
101                     "Error signalling enumerator for working set, received error: \(error!.localizedDescription, privacy: .public)"
102                 )
103             }
104         }
105     }
106
107     @objc func setupDomainAccount(
108         user: String, userId: String, serverUrl: String, password: String
109     ) {
110         let account = Account(user: user, id: userId, serverUrl: serverUrl, password: password)
111         guard account != ncAccount else { return }
112
113         Task {
114             ncKit.appendSession(
115                 account: account.ncKitAccount,
116                 urlBase: serverUrl,
117                 user: user,
118                 userId: userId,
119                 password: password,
120                 userAgent: "Nextcloud-macOS/FileProviderExt",
121                 nextcloudVersion: 25,
122                 groupIdentifier: ""
123             )
124             var authAttemptState = AuthenticationAttemptResultState.connectionError // default
125
126             // Retry a few times if we have a connection issue
127             for authTimeout in AuthenticationTimeouts {
128                 authAttemptState = await ncKit.tryAuthenticationAttempt(account: account)
129                 guard authAttemptState == .connectionError else { break }
130
131                 Logger.fileProviderExtension.info(
132                     "\(user, privacy: .public) authentication try timed out. Trying again soon."
133                 )
134                 try? await Task.sleep(nanoseconds: authTimeout)
135             }
136
137             switch (authAttemptState) {
138             case .authenticationError:
139                 Logger.fileProviderExtension.info(
140                     "\(user, privacy: .public) authentication failed due to bad creds, stopping"
141                 )
142                 return
143             case .connectionError:
144                 // Despite multiple connection attempts we are still getting connection issues.
145                 // Connection error should be provided
146                 Logger.fileProviderExtension.info(
147                     "\(user, privacy: .public) authentication try failed, no connection."
148                 )
149                 return
150             case .success:
151                 Logger.fileProviderExtension.info(
152                 """
153                 Authenticated! Nextcloud account set up in File Provider extension.
154                 User: \(user, privacy: .public) at server: \(serverUrl, privacy: .public)
155                 """
156                 )
157             }
158
159             Task { @MainActor in
160                 ncAccount = account
161                 changeObserver = RemoteChangeObserver(
162                     account: account,
163                     remoteInterface: ncKit,
164                     changeNotificationInterface: self,
165                     domain: domain
166                 )
167                 ncKit.setup(delegate: changeObserver)
168                 signalEnumeratorAfterAccountSetup()
169             }
170         }
171     }
172
173     @objc func removeAccountConfig() {
174         Logger.fileProviderExtension.info(
175             "Received instruction to remove account data for user \(self.ncAccount!.username, privacy: .public) at server \(self.ncAccount!.serverUrl, privacy: .public)"
176         )
177         ncAccount = nil
178     }
179
180     func updatedSyncStateReporting(oldActions: Set<UUID>) {
181         actionsLock.lock()
182
183         guard oldActions.isEmpty != syncActions.isEmpty else {
184             actionsLock.unlock()
185             return
186         }
187
188         let command = "FILE_PROVIDER_DOMAIN_SYNC_STATE_CHANGE"
189         var argument: String?
190         if oldActions.isEmpty, !syncActions.isEmpty {
191             argument = "SYNC_STARTED"
192         } else if !oldActions.isEmpty, syncActions.isEmpty {
193             argument = errorActions.isEmpty ? "SYNC_FINISHED" : "SYNC_FAILED"
194             errorActions = []
195         }
196         
197         actionsLock.unlock()
198
199         guard let argument else { return }
200         Logger.fileProviderExtension.debug("Reporting sync \(argument)")
201         let message = command + ":" + argument + "\n"
202         socketClient?.sendMessage(message)
203     }
204 }